Skip to content

perf(people): batch handle-alias fetch in list(), dropping an N+1#4536

Open
mysma-9403 wants to merge 4 commits into
tinyhumansai:mainfrom
mysma-9403:perf/batch-people-handles
Open

perf(people): batch handle-alias fetch in list(), dropping an N+1#4536
mysma-9403 wants to merge 4 commits into
tinyhumansai:mainfrom
mysma-9403:perf/batch-people-handles

Conversation

@mysma-9403

@mysma-9403 mysma-9403 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What

PeopleStore::list() returns every contact with its handle aliases. It fetched those aliases with a load_handles call per person, inside the row loop:

for r in rows {let handles = load_handles(&guard, &id)?;   // 1 SELECT per person}

For N contacts that's 1 + N queries (SELECT … FROM handle_aliases WHERE person_id = ? prepared + executed N times), all while holding the store lock.

Change

Collect the person rows first, then fetch all handles in one batched WHERE person_id IN (…) query keyed by person id (batch_handles_conn), windowed at 900 binds to stay under SQLite's default parameter cap. This is the pattern the same store already uses for batch_interactions_for — I mirrored it. list() drops from 1 + N queries to 1 + ceil(N/900).

load_handles stays for the single-person get() path (one query is correct there). The batch decodes each kind exactly as load_handles does, so behaviour is identical.

Is it worth it? (honest take)

SQLite is in-process, so each avoided query is cheap in absolute terms — this isn't a dramatic speedup. It's worth it because (a) it shortens how long list() holds the store lock (the whole fetch runs under one blocking_lock), (b) statement prepares + index lookups drop from O(contacts) to ~O(1) query, scaling with contact count, and (c) it follows an existing in-repo precedent, so it's low-risk and consistent rather than a speculative tweak.

Correctness

Behaviour-preserving. The batch is keyed by person_id; each person gets exactly its own aliases (ORDER BY person_id, kind, value preserves the per-person kind, value order load_handles used), and a person with no aliases comes back with an empty list (unwrap_or_default).

Tests

list_batches_handles_per_person (new) creates three people — one with two aliases, one with none, one with a single alias — and asserts each gets exactly its own handles and the alias-less person does not inherit a neighbour's (the failure mode a batched IN query risks). The existing insert_list_and_lookup_round_trip still covers the single-person handle round-trip.

cargo test --lib people::store green locally.

Summary by CodeRabbit

  • Bug Fixes
    • Improved the people list to load handle aliases in batches, reducing slowdowns from repeated lookups.
    • Ensured aliases are assigned to the correct person, preventing mismatches (including when a person has no aliases).
  • Tests
    • Added coverage to verify correct batched alias assignment and that people without aliases don’t inherit others’ aliases.

PeopleStore::list() returned every contact with its handle aliases, fetching
them with a load_handles call per person inside the row loop — 1 + N queries
(SELECT ... FROM handle_aliases WHERE person_id = ? prepared + executed once
per contact), all under the store lock.

Collect the person rows first, then fetch all aliases in one batched
WHERE person_id IN (...) query keyed by person id (batch_handles_conn),
windowed at 900 binds to stay under SQLite's default parameter cap. Mirrors
the store's existing batch_interactions_for. list() drops from 1 + N to
1 + ceil(N/900) queries and holds the lock for less time. load_handles stays
for the single-person get() path; the batch decodes each kind identically.

Tests: list_batches_handles_per_person asserts three people (two aliases, none,
one) each get exactly their own handles and the alias-less person doesn't
inherit a neighbour's — the failure mode a batched IN query risks.
@mysma-9403
mysma-9403 requested a review from a team July 5, 2026 04:49
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6153027a-5f3f-4c4c-b1c1-b4826c72bf4e

📥 Commits

Reviewing files that changed from the base of the PR and between c329383 and b484076.

📒 Files selected for processing (1)
  • src/openhuman/people/store.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/openhuman/people/store.rs

📝 Walkthrough

Walkthrough

PeopleStore::list now decodes people first, batch-loads aliases in chunked queries, and reconstructs each person from a PersonId-keyed handle map. A test verifies correct alias assignment, including people without aliases.

Changes

Batched handle fetching for list

Layer / File(s) Summary
PersonRow and batched handle loading
src/openhuman/people/store.rs
Adds PersonRow, chunked batch_handles_conn queries, and updates list to assemble people from batched aliases instead of per-person handle loads.
Per-person alias assignment test
src/openhuman/people/store.rs
Verifies distinct aliases remain associated with the correct people and that missing aliases produce empty lists.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant PeopleStore
  participant Database

  Caller->>PeopleStore: list()
  PeopleStore->>Database: query people rows
  Database-->>PeopleStore: PersonRow list
  PeopleStore->>PeopleStore: collect PersonId values
  PeopleStore->>Database: run chunked handle queries
  Database-->>PeopleStore: return handles keyed by PersonId
  PeopleStore-->>Caller: return Vec<Person>
Loading

Poem

One query hopped where many did tread,
Handles gathered in a map instead,
Each alias found its proper home,
Empty paws where none had roamed,
The rabbit cheers: no leaks to comb! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: batching handle-alias fetches in PeopleStore::list() to eliminate N+1 queries.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 5, 2026
Comment thread src/openhuman/people/store.rs Outdated
for window in ids.chunks(HANDLE_BATCH) {
if window.is_empty() {
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick (consistency): the PR mirrors batch_interactions_for, which builds placeholders with std::iter::repeat_n("?", n) (see line 491). Here you use the older std::iter::repeat("?").take(n) idiom. Since repeat_n is already used one function up in the same file, prefer it for consistency — and Clippy’s manual_repeat_n lint flags exactly this form.

// current
let placeholders = std::iter::repeat("?")
    .take(window.len())
    .collect::<Vec<_>>()
    .join(",");

// suggested — matches batch_interactions_for
let placeholders = std::iter::repeat_n("?", window.len())
    .collect::<Vec<_>>()
    .join(",");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c329383e2 — switched to std::iter::repeat_n("?", window.len()) to match batch_interactions_for and silence clippy::manual_repeat_n.

ids: &[PersonId],
) -> SqlResult<HashMap<PersonId, Vec<Handle>>> {
let mut out: HashMap<PersonId, Vec<Handle>> = HashMap::new();
for window in ids.chunks(HANDLE_BATCH) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick (dead branch): slice::chunks never yields an empty slice — for an empty ids it yields zero windows, and for non-empty ids every window has ≥1 element. So if window.is_empty() { continue; } is unreachable. Harmless, but it can be dropped. (The sibling batch_interactions_for guards emptiness once, up front, via an early return Ok(HashMap::new()) — the empty-ids case is already handled here implicitly by the zero-window loop, so no guard is needed at all.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped in c329383e2. Agreed — slice::chunks never yields an empty window (empty ids → zero windows), so the guard was unreachable. Removed it entirely; the empty-ids case is handled implicitly by the zero-window loop.

@M3gA-Mind M3gA-Mind left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #4536 — perf(people): batch handle-alias fetch in list(), dropping an N+1

Walkthrough

This PR replaces the per-person load_handles call inside PeopleStore::list()'s row loop with a single batched WHERE person_id IN (…) fetch (batch_handles_conn), windowed at 900 binds to stay under SQLite's 999 bound-parameter cap. It mirrors the store's existing batch_interactions_for precedent almost line-for-line. The change is behaviour-preserving and well-tested. Assessment: clean, low-risk, does exactly what the title claims. No blockers.

Changes

File Summary
src/openhuman/people/store.rs Adds PersonRow tuple alias + batch_handles_conn helper (chunked IN (…) handle fetch); rewrites list() to materialise rows then batch-fetch handles keyed by id; adds list_batches_handles_per_person test. load_handles retained for the single-person get() path.

Actionable comments (0 blocking)

No blockers, no majors. Two nitpicks posted inline; both optional.

Correctness — verified

  • Alias round-trip is exact. PersonId: Display writes the plain UUID (types.rs:24), matching the person.id.to_string() written by insert_person, so the IN (…) string keys resolve back correctly.
  • Per-person handle ordering is preserved. load_handles used ORDER BY kind, value; the batch uses ORDER BY person_id, kind, value. For rows of the same person_id the secondary keys yield an identical order, and results are assembled by iterating the ordered people_rows Vec (the HashMap is lookup-only), so person order (ORDER BY display_name) is preserved too.
  • Empty / no-alias cases hold. Zero people → ids empty → chunks yields no windows → empty map. A person with no aliases is absent from the map and gets unwrap_or_default()[] (the exact failure mode the new test guards against).
  • Error semantics unchanged. Unknown kind still returns Err(InvalidColumnName(...)), same as load_handles; the whole fetch still runs under one blocking_lock.
  • No dup keys. people.id is the PK, so ids has no duplicates and handles_by_id.remove(&id) is safe.

Nitpicks (2) — posted inline

  • store.rs:580 — uses std::iter::repeat("?").take(n) while the mirrored batch_interactions_for (line 491) uses std::iter::repeat_n("?", n); prefer the latter for consistency (also what Clippy's manual_repeat_n prefers).
  • store.rs:577if window.is_empty() { continue; } is unreachable: slice::chunks never yields an empty slice. Harmless dead branch.

Questions for the author (0)

None.

Verified / looks good

  • Follows the in-repo batch_interactions_for precedent; keeps load_handles for the correct single-query get() path.
  • New test exercises the real risk of a batched IN query: cross-person leakage, an alias-less person, and kind preservation.
  • spawn_blocking + error-mapping wrapper unchanged; no new lock-ordering or async concerns.

Note: CodeRabbit (CHILL profile) generated no actionable comments and auto-approved. Leaving this as a comment-only review — approval/merge is the maintainer's call.

@Coding-Dev-Tools

Copy link
Copy Markdown

🤖 Council Merge Gate — APPROVE (merge blocked by branch protection)

The model council review gate returned APPROVE (session council-e72433e9-93a7-47e5-9947-6f08c6eb1b9e), so the change is council-cleared.

However, gh pr merge was rejected by GitHub branch protection: the base branch policy prohibits the merge. This repo requires passing status checks and/or an approving review before merge, and those requirements are not currently satisfied (e.g. failing required CI checks).

Action needed (human): resolve the failing required checks / obtain the required review, then merge. A maintainer with admin rights may also merge explicitly. The PR is left OPEN.

Posted automatically by the council_gate_merge cron job.

@Coding-Dev-Tools

Copy link
Copy Markdown

🤖 Council Merge Gate — APPROVE (merge blocked by branch protection)

The model council review gate returned APPROVE (session council-2e6a89f2-ef0e-4483-8068-826ce84d716f), so the change is council-cleared.

However, gh pr merge was rejected by GitHub branch protection: the base branch policy prohibits the merge. This repo requires passing status checks and/or an approving review before merge, and those requirements are not currently satisfied (e.g. failing required CI checks).

Action needed (human): resolve the failing required checks / obtain the required review, then merge. A maintainer with admin rights may also merge explicitly. The PR is left OPEN.

Posted automatically by the council_gate_merge cron job.

@Coding-Dev-Tools

Copy link
Copy Markdown

🤖 Council Merge Gate — APPROVE (merge blocked by branch protection)

The model council review gate returned APPROVE (session council-2a5ecd70-7361-4ba7-a3e9-57c896998b86), so the change is council-cleared.

However, gh pr merge was rejected by GitHub branch protection: the base branch policy prohibits the merge. This repo requires passing status checks and/or an approving review before merge, and those requirements are not currently satisfied (e.g. failing required CI checks).

Action needed (human): resolve the failing required checks / obtain the required review, then merge. A maintainer with admin rights may also merge explicitly. The PR is left OPEN.

Posted automatically by the council_gate_merge cron job.

…d in batch_handles_conn

- repeat_n("?", n) matches the sibling batch_interactions_for idiom and
  satisfies clippy::manual_repeat_n (vs repeat().take()).
- slice::chunks never yields an empty window (empty ids -> zero windows), so
  the if window.is_empty() { continue } branch was unreachable; remove it.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 17, 2026

@senamakel senamakel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated technical review: not approved.

Blocking issue -- compilation errors on all CI lanes

The PR introduces a second type PersonRow definition (with PersonId as the first element) at the module level in src/openhuman/people/store.rs, but the pre-existing type PersonRow = (String, Option<String>, Option<String>, Option<String>, i64, i64) on the same file (lines 19-26 on main) is NOT removed. This creates a duplicate type alias at module scope, producing E0428: the name 'PersonRow' is defined multiple times and cascading type-mismatch errors (E0277: Vec<PersonId> cannot be built from iterator over String).

The Rust Quality (fmt, clippy), Rust Feature-Gate Smoke (gates off), and PR CI Gate checks all fail because of this.

Remediation: Either (a) remove the old type PersonRow = (String, ...) definition and change its first element to PersonId, or (b) rename the new type alias (e.g. PersonRowWithId) to avoid the name conflict.

N+1 claim: The refactoring approach (batched WHERE person_id IN (...) handle fetch, windowed at 900 binds) is correct and follows the existing batch_interactions_for precedent. Once the compilation error is fixed, this will be a clean N+1 fix.

main since gained a raw `type PersonRow = (String, ...)` (used by `get()`);
this branch added a decoded `type PersonRow = (PersonId, ...)` for `list()`'s
batched handle fetch. Merged together they collide at module scope (E0428) and
cascade into E0277 (pushing PersonId into a Vec<(String, ...)>). Rename the
decoded alias to `DecodedPersonRow` so both coexist; the raw and decoded rows
are genuinely different shapes and each is used by exactly one function.
@mysma-9403

Copy link
Copy Markdown
Contributor Author

Fixed in b4840763c — thanks, the finding is exactly right.

main has since gained a raw type PersonRow = (String, …) (first element is the undecoded id straight from SQLite, consumed by get()), while this branch added a decoded type PersonRow = (PersonId, …) for list()'s batched handle fetch. Merged together they collide at module scope (E0428) and cascade into the E0277 you called out (list() pushes PersonId into what the compiler resolves to a Vec<(String, …)>).

I took remediation (b): renamed the decoded alias to DecodedPersonRow rather than folding it into the existing one. The two rows are genuinely different shapes — raw String id vs already-parsed PersonId — so option (a) would have meant either making PersonId: FromSql or re-parsing in get(), both larger than the problem. A distinct name keeps each alias used by exactly one function.

Verified against the real merge, not just the branch: a local git merge --no-commit upstream/main now yields both aliases side by side — PersonRow/String in get() and DecodedPersonRow/PersonId in list() — and the merged tree compiles clean (no E0428, no E0277). The N+1 collapse itself is unchanged.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR eliminates the N+1 query pattern in PeopleStore::list() by replacing per-person load_handles calls with a single batched WHERE person_id IN (…) query, windowed at 900 parameters to stay under SQLite's 999 bind-parameter cap. The approach mirrors batch_interactions_for, reducing queries from 1 + N to 1 + ceil(N/900) while also shortening the time list() holds the store lock.

  • batch_handles_conn is introduced as a free function mirroring load_handles but operating on a slice of PersonIds, grouping results into a HashMap<PersonId, Vec<Handle>> used via .remove().unwrap_or_default() for alias-less people.
  • DecodedPersonRow is a new type alias that pre-parses PersonId from the UUID string so IDs are ready for the batched fetch without re-parsing.
  • A new test list_batches_handles_per_person covers the key correctness property: that each person receives exactly its own aliases and a person with no aliases stays empty.

Confidence Score: 5/5

Safe to merge. The batched handle fetch is behavior-preserving: per-person alias ordering matches the original, alias-less people get an empty slice, and windowing at 900 keeps every statement under SQLite's parameter cap.

The logic is correct end-to-end. Handles are grouped by person_id key so no aliasing across people is possible; unwrap_or_default correctly handles the absent-from-map case; the ORDER BY person_id, kind, value in the batch query preserves the same within-person kind, value order that load_handles produced. The drop(stmt) before calling batch_handles_conn correctly releases the connection borrow. The new test exercises all three cases and confirms no cross-person alias bleed.

No files require special attention.

Important Files Changed

Filename Overview
src/openhuman/people/store.rs Adds batch_handles_conn and DecodedPersonRow; rewrites list() to use the batched handle fetch; adds a correctness test. Logic is sound: chunking at 900 stays within SQLite's limit, ordering within each person matches load_handles, and unwrap_or_default correctly handles people with no aliases.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant list as list()
    participant SQLite

    Note over list,SQLite: Before this PR
    Caller->>list: list()
    list->>SQLite: SELECT id FROM people
    loop for each person row
        list->>SQLite: "SELECT kind FROM handle_aliases WHERE person_id = ?"
    end
    list-->>Caller: Vec of Person

    Note over list,SQLite: After this PR
    Caller->>list: list()
    list->>SQLite: SELECT id FROM people
    list->>SQLite: SELECT person_id FROM handle_aliases WHERE person_id IN ids
    list-->>Caller: Vec of Person
Loading

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." | Re-trigger Greptile

Comment on lines +583 to +585
let placeholders = std::iter::repeat_n("?", window.len())
.collect::<Vec<_>>()
.join(",");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 batch_handles_conn uses std::iter::repeat_n("?", window.len()), but the existing batch_interactions_for uses std::iter::repeat("?").take(ids.len()). repeat_n was stabilized in Rust 1.82 — if the project's MSRV is below that it won't compile. Aligning with the established pattern also makes the two batching functions easier to compare.

Suggested change
let placeholders = std::iter::repeat_n("?", window.len())
.collect::<Vec<_>>()
.join(",");
let placeholders = std::iter::repeat("?")
.take(window.len())
.collect::<Vec<_>>()
.join(",");

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +604 to +614
let h = match kind.as_str() {
"imessage" => Handle::IMessage(value),
"email" => Handle::Email(value),
"display_name" => Handle::DisplayName(value),
other => {
return Err(rusqlite::Error::InvalidColumnName(format!(
"unknown handle kind: {other}"
)));
}
};
out.entry(person_id).or_default().push(h);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated handle-kind decode logic

The match kind.as_str() arms in batch_handles_conn are a verbatim copy of those in load_handles. If a new Handle variant and its string key are ever added to load_handles, they must also be added here — and the compiler won't warn if one is missed. Extracting a small decode_handle(kind: &str, value: String) -> SqlResult<Handle> free function shared by both would eliminate the divergence risk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants